/*
* ShootOFF - Software for Laser Dry Fire Training
* Copyright (C) 2016 phrack
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.shootoff.gui.pane;
import java.io.File;
import java.io.IOException;
import java.util.Iterator;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.shootoff.Closeable;
import com.shootoff.camera.perspective.PerspectiveManager;
import com.shootoff.config.Configuration;
import com.shootoff.config.ConfigurationException;
import com.shootoff.courses.Course;
import com.shootoff.gui.CalibrationListener;
import com.shootoff.gui.CalibrationManager;
import com.shootoff.gui.CanvasManager;
import com.shootoff.gui.LocatedImage;
import com.shootoff.gui.MirroredCanvasManager;
import com.shootoff.gui.Resetter;
import com.shootoff.gui.ShotEntry;
import com.shootoff.gui.controller.ShootOFFController;
import com.shootoff.targets.Target;
import com.shootoff.util.TimerPool;
import javafx.application.Platform;
import javafx.collections.ObservableList;
import javafx.geometry.Dimension2D;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Group;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Label;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Pair;
public class ProjectorArenaPane extends AnchorPane implements CalibrationListener, Closeable {
private static final Logger logger = LoggerFactory.getLogger(ProjectorArenaPane.class);
protected Stage arenaStage;
private final Stage shootOffStage;
private final Pane trainingExerciseContainer;
private final Configuration config;
private final Group arenaCanvasGroup;
private final Label calibrationLabel;
private final CanvasManager canvasManager;
private Label mouseOnArenaLabel = null;
private Optional<LocatedImage> background = Optional.empty();
private Optional<LocatedImage> savedBackground = Optional.empty();
private Screen originalArenaHomeScreen;
private Optional<Screen> detectedProjectorScreen = Optional.empty();
private Point2D arenaScreenOrigin = new Point2D(0, 0);
private Screen arenaHome;
private CalibrationManager calibrationManager;
private Optional<PerspectiveManager> perspectiveManager = Optional.empty();
private Pair<Target, TargetDistancePane> openDistancePane;
private ProjectorArenaPane mirroredArenaPane;
// Used for testing
public ProjectorArenaPane(Configuration config, CanvasManager canvasManager) {
this.config = config;
this.canvasManager = canvasManager;
shootOffStage = null;
trainingExerciseContainer = null;
arenaCanvasGroup = new Group();
calibrationLabel = null;
}
public ProjectorArenaPane(Stage arenaStage, Stage shootOffStage, Pane trainingExerciseContainer, Resetter resetter,
ObservableList<ShotEntry> shotTimerModel) {
config = Configuration.getConfig();
arenaCanvasGroup = new Group();
calibrationLabel = new Label("Needs Calibration");
calibrationLabel.setFont(Font.font(48));
calibrationLabel.setTextFill(Color.web("#f5a807"));
calibrationLabel.setLayoutX(6);
calibrationLabel.setLayoutY(6);
calibrationLabel.setPrefSize(628, 90);
calibrationLabel.setAlignment(Pos.CENTER);
getChildren().addAll(arenaCanvasGroup, calibrationLabel);
this.shootOffStage = shootOffStage;
this.arenaStage = arenaStage;
this.trainingExerciseContainer = trainingExerciseContainer;
if (config.isHeadless()) {
canvasManager = new CanvasManager(arenaCanvasGroup, resetter, "arena", shotTimerModel);
} else {
canvasManager = new MirroredCanvasManager(arenaCanvasGroup, resetter, "arena", shotTimerModel, this);
}
setPrefSize(640, 480);
setOnKeyPressed((event) -> canvasKeyPressed(event));
setOnMouseEntered((event) -> requestFocus());
setOnMouseClicked((event) -> {
canvasManager.toggleTargetSelection(Optional.empty());
});
arenaStage.widthProperty().addListener((e) -> {
canvasManager.setBackgroundFit(arenaStage.getWidth(), arenaStage.getHeight());
});
arenaStage.heightProperty().addListener((e) -> {
canvasManager.setBackgroundFit(arenaStage.getWidth(), arenaStage.getHeight());
});
setStyle("-fx-background-color: #333333;");
}
public void setArenaPaneMirror(ProjectorArenaPane mirroredArenaPane) {
this.mirroredArenaPane = mirroredArenaPane;
}
public ProjectorArenaPane getArenaPaneMirror() {
return mirroredArenaPane;
}
public void setCalibrationManager(CalibrationManager calibrationManager) {
this.calibrationManager = calibrationManager;
}
private Optional<Screen> getStageHomeScreen(Stage stage) {
final double dpiScaleFactor = ShootOFFController.getDpiScaleFactorForScreen();
final ObservableList<Screen> stageHomeScreens = Screen.getScreensForRectangle(stage.getX() / dpiScaleFactor,
stage.getY() / dpiScaleFactor, 1, 1);
if (stageHomeScreens.isEmpty()) {
final StringBuilder message = new StringBuilder(
String.format("Didn't find screen for stage with title %s at (%f, %f)." + " Existing screens:%n",
stage.getTitle(), stage.getX() / dpiScaleFactor, stage.getY() / dpiScaleFactor));
final Iterator<Screen> it = Screen.getScreens().iterator();
while (it.hasNext()) {
final Screen s = it.next();
message.append(String.format("(w = %f, h = %f, x = %f, y = %f, dpi = %f)", s.getBounds().getWidth(),
s.getBounds().getHeight(), s.getBounds().getMinX(), s.getBounds().getMinY(), s.getDpi()));
if (it.hasNext()) {
message.append("\n");
}
}
logger.error(message.toString());
return Optional.empty();
} else if (stageHomeScreens.size() > 1) {
logger.warn("Found multiple screens as the possible arena home screen, this is unexpected: {}",
stageHomeScreens.size());
}
return Optional.of(stageHomeScreens.get(0));
}
public void autoPlaceArena() {
Optional<Screen> homeScreen = getStageHomeScreen(arenaStage);
if (homeScreen.isPresent()) {
originalArenaHomeScreen = homeScreen.get();
} else {
return;
}
// Place the arena on what we hope is the projector with the following
// precidence:
// 1. If the user has place the arena on a screen before, place it on
// that screen again
// 2. If the user has never placed the arena before and there are only
// two screens,
// put it on the screen the ShootOFF window isn't on
// 3. If the arena has never been placed and there are more than two
// screens, place
// the arena on the smallest screen
if (config.getArenaPosition().isPresent()) {
logger.debug("Projector has been manually placed previously");
final Point2D arenaPosition = config.getArenaPosition().get();
final ObservableList<Screen> screens = Screen.getScreensForRectangle(arenaPosition.getX(),
arenaPosition.getY(), 1, 1);
if (!screens.isEmpty()) {
boolean matchedOriginal = false;
for (final Screen screen : screens) {
if (originalArenaHomeScreen.equals(screen)) {
logger.debug("Stored arena coordinates are on current home screen");
matchedOriginal = true;
}
}
if (!matchedOriginal) {
arenaStage.setX(arenaPosition.getX());
arenaStage.setY(arenaPosition.getY());
Platform.runLater(() -> toggleFullScreen());
arenaHome = screens.get(0);
setArenaScreenOrigin(arenaHome);
return;
}
} else {
logger.debug("Saved screen coordinates ({}, {}) no longer exists, attempting fallback approaches...",
arenaPosition.getX(), arenaPosition.getY());
}
}
Optional<Screen> projector = Optional.empty();
if (config.isHeadless()) {
logger.debug("Headless, assuming smallest display is projector");
projector = findSmallestScreen();
} else if (Screen.getScreens().size() == 2) {
logger.debug("Two screens present");
homeScreen = getStageHomeScreen(shootOffStage);
if (!homeScreen.isPresent()) return;
final Screen shootOFFScreen = homeScreen.get();
for (final Screen screen : Screen.getScreens()) {
if (!screen.equals(shootOFFScreen)) {
projector = Optional.of(screen);
break;
}
}
} else if (Screen.getScreens().size() > 2) {
logger.debug("More than two screens present");
projector = findSmallestScreen();
}
if (projector.isPresent()) {
final double dpiScaleFactor = ShootOFFController.getDpiScaleFactorForScreen();
arenaHome = projector.get();
final double newX = arenaHome.getBounds().getMinX() * dpiScaleFactor;
final double newY = arenaHome.getBounds().getMinY() * dpiScaleFactor;
logger.debug("Found likely projector screen: resolution = {}x{}, newX = {}, newY = {}",
arenaHome.getBounds().getWidth(), arenaHome.getBounds().getHeight(), newX, newY);
arenaStage.setX(newX + 10);
arenaStage.setY(newY + 10);
detectedProjectorScreen = projector;
setArenaScreenOrigin(arenaHome);
Platform.runLater(() -> toggleFullScreen());
} else {
logger.debug("Did not find screen that is a likely projector");
}
}
private Optional<Screen> findSmallestScreen() {
Screen smallest = null;
// Find screen with the smallest area
for (final Screen screen : Screen.getScreens()) {
if (smallest == null) {
smallest = screen;
} else {
if (screen.getBounds().getHeight() * screen.getBounds().getWidth() < smallest.getBounds().getHeight()
* smallest.getBounds().getWidth()) {
smallest = screen;
}
}
}
return Optional.ofNullable(smallest);
}
public void toggleArena() {
if (arenaStage.isShowing()) {
arenaStage.hide();
} else {
arenaStage.show();
}
}
public Screen getArenaHome() {
return arenaHome;
}
public Dimension2D getArenaStageResolution() {
return new Dimension2D(arenaStage.getWidth(), arenaStage.getHeight());
}
private void setArenaScreenOrigin(Screen screen) {
final double dpiScaleFactor = ShootOFFController.getDpiScaleFactorForScreen();
final Rectangle2D arenaScreenBounds = screen.getBounds();
arenaScreenOrigin = new Point2D(arenaScreenBounds.getMinX() * dpiScaleFactor,
arenaScreenBounds.getMinY() * dpiScaleFactor);
logger.debug("Set arenaScreenOrigin to {}", arenaScreenOrigin);
}
public Point2D getArenaScreenOrigin() {
return arenaScreenOrigin;
}
public CanvasManager getCanvasManager() {
return canvasManager;
}
public Optional<PerspectiveManager> getPerspectiveManager() {
return perspectiveManager;
}
@Override
public void close() {
if (feedCanvasManager != null) {
feedCanvasManager.removeDiagnosticMessage(mouseOnArenaLabel);
}
TimerPool.cancelTimer(mouseExitedFuture);
if (Platform.isFxApplicationThread()) {
arenaStage.close();
} else {
Platform.runLater(() -> arenaStage.close());
}
}
public void setArenaBackground(LocatedImage img) {
background = Optional.ofNullable(img);
canvasManager.updateBackground(img);
}
/**
* Used to temporarily save the background before autocalibration
*/
public void saveCurrentBackground() {
if (background.isPresent()) {
savedBackground = Optional.of(background.get());
}
}
/**
* Used to restore the background that was saved before autocalibration with
* saveCurrentBackground.
*/
public void restoreCurrentBackground() {
if (savedBackground.isPresent()) {
setArenaBackground(savedBackground.get());
savedBackground = Optional.empty();
} else {
setArenaBackground(null);
}
}
public Optional<LocatedImage> getArenaBackground() {
return background;
}
public Configuration getConfiguration() {
return config;
}
public void setCourse(final Course course) {
if (course.getBackground().isPresent()) {
setArenaBackground(course.getBackground().get());
}
canvasManager.clearTargets();
final boolean scaleCourse = course.getResolution().isPresent()
&& (Math.abs(course.getResolution().get().getWidth() - getWidth()) > .0001
|| Math.abs(course.getResolution().get().getHeight() - getHeight()) > .0001);
double widthScaleFactor = 1;
double heightScaleFactor = 1;
if (scaleCourse) {
widthScaleFactor = getWidth() / course.getResolution().get().getWidth();
heightScaleFactor = getHeight() / course.getResolution().get().getHeight();
}
for (final Target t : course.getTargets()) {
if (scaleCourse) {
t.scale(widthScaleFactor, heightScaleFactor);
}
canvasManager.addTarget(t);
}
}
public void canvasKeyPressed(KeyEvent event) {
final boolean macFullscreen = event.getCode() == KeyCode.F && event.isControlDown() && event.isShortcutDown();
if (event.getCode() == KeyCode.F11 || macFullscreen) {
toggleFullScreen();
// Manually going full screen with an arena that was manually
// moved to another screen
final Optional<Screen> currentArenaScreen = getStageHomeScreen(arenaStage);
if (!currentArenaScreen.isPresent()) return;
arenaHome = currentArenaScreen.get();
setArenaScreenOrigin(arenaHome);
final boolean fullyManual = !detectedProjectorScreen.isPresent() && !arenaStage.isFullScreen()
&& !originalArenaHomeScreen.equals(currentArenaScreen.get());
final boolean movedAfterAuto = detectedProjectorScreen.isPresent() && !arenaStage.isFullScreen()
&& !detectedProjectorScreen.equals(currentArenaScreen.get());
if (fullyManual || movedAfterAuto) {
final double dpiScaleFactor = ShootOFFController.getDpiScaleFactorForScreen();
config.setArenaPosition(arenaStage.getX() / dpiScaleFactor, arenaStage.getY() / dpiScaleFactor);
try {
config.writeConfigurationFile();
} catch (ConfigurationException | IOException e) {
logger.error("Error writing configuration with arena location", e);
}
}
}
}
private void toggleFullScreen() {
arenaStage.setAlwaysOnTop(!arenaStage.isAlwaysOnTop());
arenaStage.setFullScreen(!arenaStage.isFullScreen());
calibrationManager.setFullScreenStatus(arenaStage.isFullScreen());
}
public boolean isFullScreen() {
return arenaStage.isFullScreen();
}
public void setTargetsVisible(final boolean visible) {
for (final Target t : canvasManager.getTargets())
t.setVisible(visible);
}
public void setCalibrationMessageVisible(final boolean visible) {
calibrationLabel.setVisible(visible);
}
@Override
public void startCalibration() {
setTargetsVisible(false);
if (mirroredArenaPane != null) mirroredArenaPane.mirroredStartCalibration();
}
public void mirroredStartCalibration() {
startCalibration();
}
private volatile boolean mouseInWindow = false;
private volatile boolean showingCursorWarning = false;
private ScheduledFuture<?> mouseExitedFuture = null;
private CanvasManager feedCanvasManager;
private void cursorWarningToggle(boolean mouseEntered) {
if (feedCanvasManager == null || calibrationManager == null || calibrationManager.isCalibrating()) return;
// If everything is still the same, return
if (mouseEntered && showingCursorWarning && !calibrationManager.isCalibrating()) return;
// If the mouse entered OR the mouse is in the window but we haven't
// been showing the warning, show the warning
if (mouseEntered || (mouseInWindow && !showingCursorWarning)) {
mouseInWindow = true;
if (!calibrationManager.isCalibrating() && arenaStage.isFullScreen()) {
showingCursorWarning = true;
mouseOnArenaLabel = feedCanvasManager.addDiagnosticMessage("Cursor On Arena: Shot Detection Disabled",
15000 /* ms */, Color.YELLOW);
feedCanvasManager.getCameraManager().setDetecting(false);
feedCanvasManager.getCameraManager().setDetectionLockState(true);
}
} else if (showingCursorWarning) {
mouseInWindow = false;
TimerPool.cancelTimer(mouseExitedFuture);
mouseExitedFuture = TimerPool.schedule(() -> {
if (showingCursorWarning) {
feedCanvasManager.removeDiagnosticMessage(mouseOnArenaLabel);
showingCursorWarning = false;
mouseOnArenaLabel = null;
}
if (!calibrationManager.isCalibrating()) {
// Delay restarting shot detection to minimize chance of
// false shots being detected when the mouse moves
try {
Thread.sleep(500 /* ms */);
} catch (final Exception e) {
logger.error("Exception thrown when re-enabling shot detection due to mouse leaving arena", e);
}
feedCanvasManager.getCameraManager().setDetectionLockState(false);
feedCanvasManager.getCameraManager().setDetecting(true);
}
}, 100 /* ms */);
}
}
@Override
public void calibrated(Optional<PerspectiveManager> perspectiveManager) {
setCalibrationMessageVisible(false);
setTargetsVisible(true);
cursorWarningToggle(false);
this.perspectiveManager = perspectiveManager;
// Now that we've calibrated and have a new perspective manager,
// go through all current arena targets and try to resize them
// to their default real world heights
if (perspectiveManager.isPresent()) {
for (final Target t : canvasManager.getTargets()) {
resizeTargetToDefaultPerspective(t);
}
if (perspectiveManager.get().isInitialized() && mirroredArenaPane != null) {
// Do not play a chime for this message
final Label successLabel = mirroredArenaPane.getCanvasManager().addDiagnosticMessage(
"Perspective Fully Initialized -- Using Real World Distances", -1, Color.LIMEGREEN);
TimerPool.schedule(() -> mirroredArenaPane.getCanvasManager().removeDiagnosticMessage(successLabel),
5000);
}
}
if (mirroredArenaPane != null) mirroredArenaPane.mirrorCalibrated(perspectiveManager);
}
public void mirrorCalibrated(Optional<PerspectiveManager> perspectiveManager) {
calibrated(perspectiveManager);
}
/**
* This methods is used by {@link com.shootoff.gui.MirroredCanvasArena} to
* notify the arena controller of new targets added to the arena. Without
* this method the targets would be added directly to the arena's canvas
* manager, bypassing the arena controller. Thus, the arena controller would
* not be able to configure arena-specific target operations (e.g. setting a
* targets distance).
*
* @param target
* a new target that was just added to the projector arena
*/
public void targetAdded(Target target) {
resizeTargetToDefaultPerspective(target);
if (target.tagExists(Target.TAG_FILL_CANVAS) && Boolean.parseBoolean(target.getTag(Target.TAG_FILL_CANVAS))) {
target.fillParent();
}
target.setTargetSelectionListener((toggledTarget, isSelected) -> {
if (!isSelected) {
if (perspectiveManager.isPresent() && openDistancePane != null)
trainingExerciseContainer.getChildren().remove(openDistancePane.getValue());
return;
}
if (perspectiveManager.isPresent()) {
if (openDistancePane != null)
trainingExerciseContainer.getChildren().remove(openDistancePane.getValue());
openDistancePane = new Pair<>(target,
new TargetDistancePane(toggledTarget, perspectiveManager.get(), config));
trainingExerciseContainer.getChildren().add(openDistancePane.getValue());
} else {
showPerspectiveUsageMessage();
}
});
if (mirroredArenaPane != null) mirroredArenaPane.mirrorTargetAdded(target);
}
public void mirrorTargetAdded(Target target) {
targetAdded(target);
}
public void targetRemoved(Target target) {
if (openDistancePane != null && target.equals(openDistancePane.getKey()))
trainingExerciseContainer.getChildren().remove(openDistancePane.getValue());
}
/**
* Used by the {@link com.shootoff.gui.CalibrationManager} to notify the
* arena controller of the canvas manager that belongs to the camera used to
* detect shots on the arena. This particular canvas manager is used by the
* arena controller to display arena specific diagnostic messages on the
* webcam feed pointed at the projection. It is also used to disable shot
* detection when necessary to enhance user experience (e.g. when the mouse
* cursor is on the arena because a user is resizing targets).
*
* @param canvasManager
* the canvas manager showing the webcam feed pointed at the
* projection for the projection arena controlled by this class
*/
public void setFeedCanvasManager(CanvasManager canvasManager) {
feedCanvasManager = canvasManager;
if (arenaStage != null) {
arenaStage.getScene().setOnMouseMoved((event) -> {
// Only disable shot detection if the cursor is actually
// over the stage. We may inject mouse movements later to
// allow targets to be moved around on the arena from the
// calibrated camera feed.
if (event.getScreenX() >= arenaStage.getX() && event.getScreenX() < arenaStage.getX() + getWidth()
&& event.getScreenY() >= arenaStage.getY()
&& event.getScreenY() < arenaStage.getY() + getHeight()) {
cursorWarningToggle(true);
}
});
arenaStage.getScene().setOnMouseEntered((event) -> {
cursorWarningToggle(true);
});
arenaStage.getScene().setOnMouseExited((event) -> {
cursorWarningToggle(false);
this.canvasManager.toggleTargetSelection(Optional.empty());
});
}
}
public void resizeTargetToDefaultPerspective(Target target) {
if (perspectiveManager.isPresent() && target.tagExists(Target.TAG_DEFAULT_PERCEIVED_WIDTH)
&& target.tagExists(Target.TAG_DEFAULT_PERCEIVED_HEIGHT)
&& target.tagExists(Target.TAG_DEFAULT_PERCEIVED_DISTANCE)) {
final PerspectiveManager pm = perspectiveManager.get();
if (pm.isInitialized()) {
final int width = Integer.parseInt(target.getTag(Target.TAG_DEFAULT_PERCEIVED_WIDTH));
final int height = Integer.parseInt(target.getTag(Target.TAG_DEFAULT_PERCEIVED_HEIGHT));
final int distance = Integer.parseInt(target.getTag(Target.TAG_DEFAULT_PERCEIVED_DISTANCE));
final Optional<Dimension2D> targetDimensions = pm.calculateObjectSize(width, height, distance);
if (targetDimensions.isPresent()) {
final Dimension2D d = targetDimensions.get();
target.setDimensions(d.getWidth(), d.getHeight());
}
}
}
}
private void showPerspectiveUsageMessage() {
if (config.showedPerspectiveMessage() || config.inDebugMode()) return;
final Alert recalibrationAlert = new Alert(AlertType.INFORMATION);
final String message = "Did you know that ShootOFF supports configuring targets to appear at real "
+ "world distances? ShootOFF supports this feature out of the box for some cameras, but "
+ "with your camera you need to recalibrate the arena with the perspective "
+ "calibration pattern.\n\nPlease print out this file and tape it to your wall or "
+ "projector screen in view of the camera pointed at your projection:\n\n"
+ System.getProperty("shootoff.home") + File.separator + "targets" + File.separator
+ "PerspectiveCalibrationPattern.pdf\n\nThen hit Projector -> Start Calibrating.\n\n"
+ "This message will only show once, so take notes for future reference!";
recalibrationAlert.setTitle("Real World Distances");
recalibrationAlert.setHeaderText("Add the Paper Pattern and Use Real World Target Distances!");
recalibrationAlert.setResizable(true);
recalibrationAlert.setContentText(message);
if (shootOffStage != null) recalibrationAlert.initOwner(shootOffStage);
recalibrationAlert.showAndWait();
config.setShowedPerspectiveMessage(true);
try {
config.writeConfigurationFile();
} catch (ConfigurationException | IOException e) {
logger.error("Failed to persist showed perspective manager settings.", e);
}
}
}